CasaTrade Elasticsearch 장애 시 검색 fallback 설계
CasaTrade Elasticsearch 장애 시 검색 fallback 설계
Elasticsearch가 느리거나 중단됐을 때 모든 검색을 DB의 %LIKE%로 넘기면 검색 API는 살아나도 DB가 연쇄 장애를 일으킬 수 있다. 쿼리 종류별로 허용 가능한 대체 기능을 정의하고, 짧은 timeout과 circuit breaker로 장애를 격리하며, 응답에는 degraded와 결과 출처를 표시해야 한다. 검색 엔진이 회복된 뒤에는 단순 health check를 넘어 색인 최신성도 확인한다.
CasaTrade의 카탈로그·가격 검색 경계를 배경으로 하지만 아래 인덱스, 쿼리, timeout과 코드는 설명용 재구성 예시다. 실제 운영 구성이나 상품 데이터는 사용하지 않았다.
목차
- #검색 엔진 장애가 상품 API 장애로 번지는 과정
- #fallback은 같은 품질의 대체제가 아니다
- #쿼리 종류별로 대체 경로 정하기
- #짧은 timeout과 circuit breaker로 격리하기
- #DB fallback에 반드시 예산을 두기
- #degraded 상태를 응답에 표시하기
- #캐시를 fallback으로 사용할 때
- #쓰기와 색인 지연을 별도로 다루기
- #회복은 health check 한 번으로 끝나지 않는다
- #TypeScript 형태의 재구성 예시
- #재시도와 동시 요청 폭주 막기
- #테스트할 장애 시나리오
- #운영 지표와 Runbook
- #결론
- #관련 노트
검색 엔진 장애가 상품 API 장애로 번지는 과정
상품 상세 API가 비슷한 상품, 검색 추천과 가격 evidence를 한 응답에 모두 포함하면 Elasticsearch 장애가 핵심 조회까지 막을 수 있다.
flowchart LR
C[Client] --> A[Product API]
A --> D[Primary DB]
A --> E[Elasticsearch]
E -->|timeout| A
A -->|connection 대기| P[Worker Pool 고갈]
P --> X[전체 API 지연]한 요청이 검색 timeout을 10초 기다리고 트래픽이 100개 쌓이면 애플리케이션 connection과 메모리가 점유된다. 사용자는 상품 기본 정보조차 보지 못한다.
첫 질문은 “Elasticsearch를 어떻게 살릴까”가 아니라 “검색이 없어도 어떤 사용자 작업을 계속 제공할 수 있는가”다.
- 상품 ID로 상세 조회는 DB만으로 가능하다.
- 정확한 상품 코드 조회는 DB 유일 인덱스로 대체할 수 있다.
- 자유 텍스트 relevance 검색은 품질이 크게 떨어진다.
- 이미지 유사도 검색은 관계형 DB로 대체하기 어렵다.
- 최근 본 상품은 사용자 캐시로 제공할 수 있다.
모든 검색 기능을 한 fallback으로 묶지 않는다.
fallback은 같은 품질의 대체제가 아니다
fallback은 정상 경로보다 기능이 제한된 degraded mode다. 이를 숨기면 사용자는 검색 결과가 없는 것을 “상품이 존재하지 않는다”로 오해한다.
| 정상 경로 | fallback | 잃는 기능 |
|---|---|---|
| 형태소·동의어 검색 | DB prefix | 오타, 동의어, relevance |
| 복합 필터·facet | 최근 캐시 | 최신성, 전체 범위 |
| 이미지 유사도 | 사용자가 텍스트 입력 | 이미지 기반 후보 |
| 가격 evidence 검색 | 마지막 성공 snapshot | 신규 거래 반영 |
| 자동완성 | 인기 검색어 캐시 | 개인화와 새 상품 |
fallback 결과를 정상 결과와 같은 source=search로 반환하면 품질 저하율을 측정할 수 없다. 결과 객체에 실행 경로와 제한을 넣는다.
interface SearchMeta {
mode: "primary" | "fallback" | "unavailable";
source: "elasticsearch" | "database_prefix" | "cache";
degradedReasons: string[];
resultAsOf?: string;
}
쿼리 종류별로 대체 경로 정하기
검색 요청을 먼저 분류한다.
type SearchIntent =
| { kind: "exact_code"; code: string }
| { kind: "prefix"; normalizedName: string }
| { kind: "full_text"; query: string }
| { kind: "image_similarity"; assetId: string }
| { kind: "browse_category"; categoryId: number };
| intent | ES 장애 시 정책 |
|---|---|
| exact_code | DB unique lookup |
| prefix | 길이와 결과 수를 제한한 DB prefix |
| full_text | 최근 cache, 없으면 기능 제한 안내 |
| image_similarity | 텍스트 검색 유도, 자동 DB 전환 금지 |
| browse_category | DB의 인덱스된 목록 |
사용자 입력을 그대로 %query%로 만드는 것은 인덱스를 활용하기 어렵고 wildcard 문자를 의도치 않게 해석할 수 있다. prefix fallback은 정규화된 접두 검색만 허용한다.
SELECT product_id, display_name, thumbnail_key
FROM catalog_product
WHERE normalized_name >= :prefix
AND normalized_name < :prefix_upper_bound
AND status = 'ACTIVE'
ORDER BY normalized_name, product_id
LIMIT 20;
또는 DB와 collation 특성에 맞는 LIKE 'prefix%'를 사용하고 EXPLAIN으로 확인한다. 관련 내용은 LIKE 검색이 인덱스를 타는 조건과 연결된다.
짧은 timeout과 circuit breaker로 격리하기
fallback이 있어도 primary timeout을 오래 기다리면 응답은 여전히 느리다.
const result = await searchEngine.search(query, {
timeoutMs: 450,
signal: requestSignal,
});
timeout 숫자는 예시다. 기능의 전체 지연 예산에서 네트워크, fallback과 응답 직렬화 시간을 빼고 정한다.
circuit breaker는 연속 실패 중인 검색 엔진을 매 요청이 다시 두드리지 않게 한다.
stateDiagram-v2
[*] --> Closed
Closed --> Open: 실패율·지연 임계 초과
Open --> HalfOpen: cooldown 경과
HalfOpen --> Closed: 제한 probe 성공
HalfOpen --> Open: probe 실패async function searchWithBreaker(query: SearchQuery) {
return searchBreaker.execute(
() => elasticsearch.search(query),
() => fallbackRouter.search(query),
);
}
인스턴스마다 breaker가 따로 있으면 일부 인스턴스가 계속 probe를 보낼 수 있다. 먼저 로컬 breaker로 단순하게 시작하되 전체 의존성 지표와 load balancer 상태를 함께 본다.
DB fallback에 반드시 예산을 두기
검색 트래픽을 DB로 모두 옮기면 평소보다 훨씬 많은 쿼리가 primary DB에 몰린다.
- fallback 동시 실행 수 제한
- 사용자·IP별 rate limit
- 최소 검색어 길이
- prefix만 허용
- 결과 수와 탐색 범위 제한
- replica 사용 가능성 검토
- statement timeout
- DB 부하가 높으면 fallback 자체를 차단
async function guardedDatabaseFallback(intent: SearchIntent) {
if (!fallbackBudget.tryAcquire()) {
return unavailable("FALLBACK_CAPACITY_EXHAUSTED");
}
try {
return await withTimeout(
databaseSearch.findPrefix(intent, { limit: 20 }),
180,
);
} finally {
fallbackBudget.release();
}
}
fallback의 목적은 검색 API 성공률 100%가 아니라 전체 시스템을 보호하며 최소 기능을 제공하는 것이다. DB가 위험해지면 검색 기능을 제한하고 상품 ID 직접 조회를 유지하는 편이 낫다.
primary가 실패한 모든 요청을 용량이 더 작은 fallback으로 보내면 장애를 다른 시스템으로 옮길 뿐이다.
degraded 상태를 응답에 표시하기
{
"items": [
{
"id": 42,
"name": "Example Camera"
}
],
"meta": {
"mode": "fallback",
"source": "database_prefix",
"degradedReasons": ["semantic_search_unavailable"],
"resultAsOf": "2026-08-04T02:10:00Z"
}
}
클라이언트는 다음처럼 표현할 수 있다.
현재 고급 검색이 일시적으로 제한되어 상품명 앞부분이 일치하는 결과만 보여 드립니다.
items: []만 반환하면 정말 결과가 없는지 검색을 수행하지 못한 것인지 알 수 없다. HTTP 200으로 제한 결과를 반환하더라도 meta는 필요하다. 어떤 의도도 대체할 수 없다면 503과 재시도 가능 정보를 반환할 수 있다.
가격 추정에 fallback evidence를 사용했다면 가격 결과에도 source와 기준 시각을 전달한다. 오래된 snapshot을 현재 가격처럼 표시하지 않는다.
캐시를 fallback으로 사용할 때
캐시는 DB 부하가 적고 빠르지만 stale data와 권한 문제가 있다.
interface CachedSearchResult {
normalizedQueryHash: string;
indexGeneration: string;
items: SearchItem[];
createdAt: string;
expiresAt: string;
visibilityScope: string;
}
캐시 키에는 정규화 쿼리, 필터, 정렬, locale과 권한 범위가 필요하다. 관리자 전용 상품 결과를 공개 사용자 캐시에 재사용하면 안 된다.
stale-while-error 정책을 사용할 수 있다.
fresh TTL: 정상 경로에서 바로 사용
stale TTL: 검색 장애일 때만 제한적으로 사용
hard expiry: 어떤 상황에서도 사용하지 않음
응답에 resultAsOf를 포함하고, 재고·판매 가능 여부처럼 오래된 값이 위험한 필드는 DB에서 다시 검증하거나 결과에서 제외한다.
쓰기와 색인 지연을 별도로 다루기
Elasticsearch가 정상이어도 DB에 방금 만든 상품이 아직 색인되지 않았을 수 있다. 이는 availability 장애가 아니라 consistency 문제다.
sequenceDiagram
participant A as Admin API
participant D as DB
participant O as Outbox
participant I as Indexer
participant E as Elasticsearch
A->>D: 상품 저장
A->>O: ProductChanged
O->>I: 이벤트 전달
I->>E: index document
E-->>I: generation 기록관리자가 방금 만든 상품을 즉시 찾아야 한다면 write 응답의 ID로 DB 상세를 보여 주거나 짧은 read-your-write overlay를 둔다. 모든 사용자 검색을 DB fallback으로 전환할 필요는 없다.
색인 문서에는 source DB version을 넣고, update가 순서 뒤바뀜으로 오래된 문서를 덮지 않게 외부 버전이나 조건부 갱신을 사용한다.
회복은 health check 한 번으로 끝나지 않는다
클러스터가 200을 반환한다고 검색 결과가 최신인 것은 아니다. 장애 동안 outbox backlog가 쌓였거나 일부 shard가 복구 중일 수 있다.
회복 조건:
- probe 쿼리가 시간 예산 안에 성공
- 오류율과 p95 지연이 안정 구간으로 복귀
- 필요한 인덱스와 alias가 존재
- indexer backlog가 허용 범위 아래
- DB version과 index generation 차이가 허용 범위 안
- 대표 golden query가 최소 결과를 반환
Half-open 상태에서 일부 트래픽만 primary로 보내고 성공을 확인한 뒤 점진적으로 복구한다.
function choosePrimaryTrafficRatio(health: SearchHealth) {
if (health.state === "open") return 0;
if (health.state === "half_open") return 0.02;
return 1;
}
장애 중 캐시된 fallback 결과도 primary 회복 후 자연히 만료되거나 generation 변화로 무효화해야 한다.
TypeScript 형태의 재구성 예시
type SearchResult =
| {
status: "ok";
items: SearchItem[];
meta: SearchMeta;
}
| {
status: "unavailable";
items: [];
meta: SearchMeta;
retryAfterSeconds: number;
};
class ResilientCatalogSearch {
constructor(
private readonly primary: CatalogSearchPort,
private readonly fallback: SearchFallbackRouter,
private readonly breaker: CircuitBreaker,
private readonly metrics: SearchMetrics,
) {}
async search(
intent: SearchIntent,
signal: AbortSignal,
): Promise<SearchResult> {
if (!this.breaker.allowsRequest()) {
return this.runFallback(intent, "circuit_open");
}
try {
const items = await withTimeout(
this.primary.search(intent, { signal }),
450,
signal,
);
this.breaker.recordSuccess();
return {
status: "ok",
items,
meta: {
mode: "primary",
source: "elasticsearch",
degradedReasons: [],
},
};
} catch (error) {
const kind = classifySearchFailure(error);
this.breaker.recordFailure(kind);
this.metrics.primaryFailure(kind);
return this.runFallback(intent, kind);
}
}
private async runFallback(
intent: SearchIntent,
reason: string,
): Promise<SearchResult> {
const result = await this.fallback.search(intent);
this.metrics.fallback(result.meta.source, reason);
return result;
}
}
모든 오류를 breaker 실패로 세지 않는다. 잘못된 사용자 쿼리의 400이나 존재하지 않는 인덱스 설정 오류는 다른 방식으로 처리한다. timeout, connection 오류와 5xx 등 의존성 건강 신호를 분류한다.
fallback router:
class SearchFallbackRouter {
async search(intent: SearchIntent): Promise<SearchResult> {
switch (intent.kind) {
case "exact_code":
return dbExactSearch(intent);
case "prefix":
return guardedDatabaseFallback(intent);
case "browse_category":
return cachedCategoryOrDatabase(intent);
case "full_text":
return staleCacheOrUnavailable(intent);
case "image_similarity":
return unavailableResult(
"IMAGE_SEARCH_TEMPORARILY_UNAVAILABLE",
);
}
}
}
재시도와 동시 요청 폭주 막기
사용자 요청 안에서 Elasticsearch를 여러 번 즉시 재시도하면 지연과 부하가 증가한다. 읽기 요청이라도 남은 deadline과 오류 종류를 기준으로 제한한다.
동일 쿼리가 동시에 몰릴 때 single-flight로 primary 호출이나 cache refresh를 합칠 수 있다.
const result = await searchFlights.run(
stableSearchKey(intent),
() => resilientSearch.search(intent, signal),
);
클라이언트, API gateway, 애플리케이션과 SDK가 각각 재시도하면 실제 호출 수가 곱해진다. 한 계층을 책임자로 정하고 지수 백오프와 jitter를 적용한다. 재시도에 지수 백오프와 지터가 필요한 이유와 Circuit Breaker로 연쇄 장애 줄이기가 연결되는 지점이다.
테스트할 장애 시나리오
| 시나리오 | 기대 결과 |
|---|---|
| primary가 timeout | 허용 intent만 fallback |
| circuit open | primary 호출 없이 즉시 분기 |
| DB fallback 포화 | 검색 제한, DB 보호 |
| exact code 조회 | DB 인덱스로 정확히 반환 |
| image similarity 장애 | prefix 결과로 위장하지 않음 |
| stale cache 존재 | 기준 시각과 함께 반환 |
| 권한 범위가 다른 cache | 재사용 금지 |
| ES 회복, indexer backlog 큼 | half-open 유지 |
| 오래된 색인 이벤트가 늦게 도착 | 새 version 덮어쓰기 금지 |
| 모든 의존성 실패 | 명시적 unavailable |
장애 테스트에서는 단순 mock exception뿐 아니라 느린 응답, 부분 shard 실패, malformed response와 connection pool 고갈을 주입한다.
운영 지표와 Runbook
| 지표 | 확인할 문제 |
|---|---|
| primary success·timeout rate | ES 가용성과 지연 |
| circuit state by instance | open·half-open 분포 |
| fallback rate by intent | 기능별 품질 저하 |
| DB fallback concurrency | 연쇄 장애 위험 |
| stale cache age | 결과 최신성 |
| unavailable rate | 대체 불가능한 검색 |
| indexing backlog age | 회복 후 데이터 지연 |
| DB-index version lag | 일관성 차이 |
| degraded response CTR | 제한 결과가 실제로 유용한지 |
Runbook에는 breaker를 강제로 닫는 버튼보다 먼저 다음을 넣는다.
- search cluster와 index alias 상태 확인
- 애플리케이션 fallback과 DB 부하 확인
- indexer backlog와 outbox 확인
- 대표 query 결과 비교
- half-open traffic의 오류·지연 확인
- 점진적 정상 전환
수동으로 breaker를 닫아 장애 트래픽을 한꺼번에 되돌리지 않는다.
결론
Elasticsearch 장애에 catch를 추가하고 DB 검색을 호출하는 것만으로는 복원력이 생기지 않는다. 검색 트래픽이 primary DB로 몰리면 핵심 상품 조회까지 함께 무너질 수 있고, 이미지 유사도처럼 의미 있는 대체가 없는 기능도 있다.
핵심은 검색 intent별로 허용 가능한 최소 기능을 정하고, 짧은 timeout과 circuit breaker로 primary 장애를 격리하며, fallback 용량과 최신성에 명시적인 예산을 두는 것이다.
응답에는 degraded 상태와 source, 기준 시각을 표시해야 한다. 회복도 health endpoint 성공이 아니라 인덱스 alias, backlog, version lag와 대표 쿼리까지 확인하고 점진적으로 진행해야 한다. 그래야 fallback이 검색 장애를 숨기는 코드가 아니라 전체 시스템을 보호하는 운영 모드가 된다.